#! /usr/bin/env python
# -*- coding: utf-8 -*-
"""
@File Name: mailFetcher.py
@Author: LTisme
@Date: 2021/8/28 10:35
@Description: ###############################################################################
              这其中定义的类承担着作为POP邮件服务器的接口的工作：提取，删除和同步化；
              相关文档和测试参考__init__文件
              ###############################################################################
"""

import poplib, sys
from .. import 邮件配置模块_mailconfig
from .mailParser import MailParser                                      # 为了进行题头匹配
from .mailTool类 import MailTool, SilentMailTool                         # 追踪控制超类

print('user:', 邮件配置模块_mailconfig.popusername)


# 索引/服务器消息编号不同步时候的异常类
class DeleteSynchError(Exception): pass                                 # 删除时信息不同步异常
class TopNotSupported(Exception): pass                                  # 不能运行同步化测试异常
class MessageSynchError(Exception): pass                                # 索引列表不同步异常


class MailFetcher(MailTool):
    """
    抓取邮件：连接，抓取题头和邮件，删除邮件——可在任何带有Python和互联网的机器上运行；
    通过POP协议把这个类子类化来实现缓存；
    IMAP要求新类；
    抓取时为解析器进行全部邮件文本的编码；
    """

    def __init__(self, popserver=None, popuser=None, poppswd=None, hastop=True):
        self.popServer = popserver or 邮件配置模块_mailconfig.popservername
        self.popUser = popuser or 邮件配置模块_mailconfig.popusername
        self.srvrHasTop = hastop
        self.popPassword = poppswd                                      # 如果None则稍后询问

    def connect(self):
        self.trace('Connecting...')
        self.getPassword()                                              # 文件，GUI或控制台
        server = poplib.POP3(self.popServer, timeout=20)
        server.user(self.popUser)                                       # 连接，登录POP服务器
        server.pass_(self.popPassword)                                  # pass是Python的保留字
        self.trace(server.getwelcome())                                 # 打印欢迎信息
        return server

    # 使用客户端的mailconfig中的设置；
    # 可在类或实例中修改以进行定制
    fetchEncoding = 邮件配置模块_mailconfig.fetchEncoding

    def decodeFullText(self, messageBytes: '字节形式的 消息列表') -> list[str]:
        """
        Py3.1中： 将抓取到的全部邮件文本字节解码成str Unicode字符，抓取时完成，以备后续的显示或解析（这样此后完整邮件文本一直都是Unicode）；
        用每个类或每个实例的设置或常用类型进行解码；
        还可以尝试题头检查，或者根据结构进行智能推测；
        
        在Python3.2之后的版本则不需要这一步；
        若不需要，则修改代码，返回完整的消息行列表即可；
        
        由于最初的标准是ASCII，latin-1等8bit编码，大概能胜任大多数邮件的编码；
        这个方法应用于整个/全消息文本，而后者其实只是邮件编码故事的一部分：Message负载和Message题头也可能按照email，MIME和Unicode标准进行编码；
        """
        text = None
        kinds = [self.fetchEncoding]                                    # 先尝试用户的设置
        kinds += ['ascii', 'latin1', 'utf8']                            # 再尝试常用类型
        kinds += [sys.getdefaultencoding()]                             # 以及操作平台默认设置（这个可能因不同机器而异）
        for kind in kinds:                                              # 可能导致邮件保存失败
            try:
                text = [line.decode(kind) for line in messageBytes]
                break                                                   # 遇到能解码的直接解码并跳出来
            except (UnicodeError, LookupError):                         # LookupError: bad name
                pass

        if text is None:
            # 尝试返回题头和错误消息，否则异常可能中止客户端；
            # 仍然尝试依照ascii，其他，以及系统平台特异的设置解码题头；

            blankline = messageBytes.index(b'')
            hdrsonly = messageBytes[:blankline]
            commons = ['ascii', 'latin1', 'utf8']
            for common in commons:
                try:
                    text = [line.decode(common) for line in hdrsonly]
                    break
                except UnicodeError:
                    pass
            else:  # 都不可行
                try:
                    text = [line.decode() for line in hdrsonly]         # 使用平台默认的编码解码？
                except UnicodeError:
                    text = ['From: (sender of unknown Unicode format headers)']
            text += ['', '--Sorry: mailtools cannot decode this mail content!--']
        return text

    def downloadMessage(self, msgnum):
        """
        载入给定POP相对消息编号的邮件消息的全部原始文本，调用函数必须解析内容
        """
        self.trace('load ' + str(msgnum))
        server = self.connect()
        try:
            resp, msglines, respsz = server.retr(msgnum)                # 开始抓取
        finally:
            server.quit()
        msglines = self.decodeFullText(msglines)                        # 原始bytes解码成Unicode str
        return '\n'.join(msglines)                                      # 连接各行，以便解析

    def downloadAllHeaders(self, progress=None, loadfrom=1):
        """
        获取所有消息或新消息的大小，仅针对原始题头文本；
        从消息编号loadfrom开始载入题头；
        使用loadfrom仅载入新到达邮件；
        稍后使用downloadMessage函数获取全部消息文本；
        progress是一个函数，调用形式(count, total)；
        返回：[headers text], [mail sizes], loadedfull?

        添加了mailconfig.fetchlimit来支持大型邮件收件箱：如果不是None
        则只抓取指定数量的题头，将其他邮件作为假/空邮件返回；
        """
        if not self.srvrHasTop:                                         # 并不是所有的服务器都支持TOP
            # 不做更改地载入所有原始消息文本
            return self.downloadAllMsgs(progress, loadfrom)
        else:
            self.trace('loading headers')
            fetchlimit = 邮件配置模块_mailconfig.fetchlimit
            server = self.connect()                                     # 邮箱在退出前一直处于锁定状态
            try:
                resp, msginfos, respsz = server.list()                  # 多行'编号大小'组成的列表
                msgCount = len(msginfos)                                # 相当于srvr.stat[0]，即消息总数
                msginfos = msginfos[loadfrom - 1:]                      # 去除已载入的
                allsizes = [int(x.split()[1]) for x in msginfos]
                allhdrs = []
                for msgnum in range(loadfrom, msgCount + 1):            # 可能是空的
                    if progress:
                        progress(msgnum, msgCount)                      # 运行回调函数
                    if fetchlimit and (msgnum <= msgCount - fetchlimit):    # 若fetchlimit不为空且
                        # 跳过，并添加假地址
                        hdrtext = 'Subject: --mail skipped--\n\n'
                        allhdrs.append(hdrtext)
                    else:
                        # 抓取，仅提取题头
                        resp, hdrlines, respsz = server.top(msgnum, 0)
                        hdrlines = self.decodeFullText(hdrlines)
                        allhdrs.append('\n'.join(hdrlines))
            finally:
                server.quit()                                           # 确保解锁邮箱
            assert len(allhdrs) == len(allsizes)
            self.trace('load headers exit')
            return allhdrs, allsizes, False

    def downloadAllMessages(self, progress=None, loadfrom=1):
        """
        载入编号从loadfrom到N的所有信息的完整信息文本，虽然调用函数可能进行过缓存；
        如果只需要题头，比downloadAllHeaders会慢得多。
        """
        self.trace('loading full messages')
        fetchlimit = 邮件配置模块_mailconfig.fetchlimit
        server = self.connect()
        try:
            (msgCount, msgBytes) = server.stat()                        # 服务器收件箱
            allmsgs = []
            allsizes = []
            for i in range(loadfrom, msgCount + 1):                     # 如果 low >= high，则内容为空
                if progress:
                    progress(i, msgCount)
                if fetchlimit and (i <= msgCount - fetchlimit):
                    # 跳过，并添加假地址
                    mailtext = 'Subject: --mail skipped--\n\nMail skipped.\n'
                    allmsgs.append(mailtext)
                    allsizes.append(len(mailtext))
                else:
                    # 抓取并提取整个消息
                    (resp, message, respsz) = server.retr(i)            # 保存的消息在message列表中
                    message = self.decodeFullText(message)
                    allmsgs.append('\n'.join(message))                  # 将邮件留在服务器中
                    allsizes.append(respsz)                             # 与len(msg)不同
        finally:
            server.quit()                                               # 解锁邮箱
        assert len(allmsgs) == (msgCount - loadfrom) + 1                # 邮件编号从1开始
        # assert sum(allsizes) == msgBytes                              # if loadfrom > 1 则不操作
        return allmsgs, allsizes, True                                  # if fetchlimit 则不操作

    def deleteMessages(self, msgnums, progress=None):
        """
        从服务器删除多条消息；
        假定自从上一次获取/载入消息编号后邮箱未发生变化；在消息题头不能用作状态信息时使用；
        快速，不过可能有风险：参考deleteMessagesSafely；
        """
        self.trace('deleting mails')
        server = self.connect()
        try:
            for (ix, msgnum) in enumerate(msgnums):                     # 删除的话不需要每条消息都要进行重新连接
                if progress:
                    progress(ix + 1, len(msgnums))
                server.dele(msgnum)
        finally:  # 退出后会进行删除，然后会更改消息编号，下次重新载入时会看到
            server.quit()

    def deleteMessagesSafely(self, msgnums, synchHeaders, progress=None):
        """
        从服务器删除多条消息，不过在删除前使用TOP抓取来检查每条消息的题头部分是否匹配；
        假定电子邮件服务器支持POP的TOP接口，否则抛出TopNotSupport异常————此时客户端只能deleteMessages了；
        在邮件服务器上一次抓取电子邮件索引后可能更改邮箱，以致改变TOP相对消息编号时使用；
        如果电子邮件由另一个客户端删除，则可能导致这种情况；此外某些ISP可能在下载失败后将邮件从收件箱移动到无法传送邮件箱；

        synchHeaders必须是一个已载入邮件题头文本组成的列表，与选中的邮箱编号相对应（要求提供状态）；
        如果其中任何一个不与电子邮件服务器同步则抛出异常；
        收件箱在退出前一直锁定，所以TOP检查和实际删除之间应该没有变化：同步化检查必须在这里面非调用函数中进行；
        调用checkSynchError和deleteMessages可能足够，不过在这里检查每条消息，以防止收件箱中部分发生删除和插入；
        """
        if not self.srvrHasTop:
            raise TopNotSupported('Safe delete cancelled')

        self.trace('deleting mails safely')
        errmsg = 'Message %s out of synch with server.\n'
        errmsg += 'Delete terminated at this message.\n'
        errmsg += 'Mail client may require restart or reload.'

        server = self.connect()                                         # 锁定收件箱，直到退出
        try:                                                            # 不要每条信息都重新连接
            (msgCount, msgBytes) = server.stat()                        # 服务器收件箱的大小
            for (ix, msgnum) in enumerate(msgnums):
                if progress:
                    progress(ix + 1, len(msgnums))
                if msgnum > msgCount:                                   # 删除了消息
                    raise DeleteSynchError(errmsg % msgnum)
                resp, hdrlines, respsz = server.top(msgnum, 0)          # 只针对题头
                hdrlines = self.decodeFullText(hdrlines)
                msghdrs = '\n'.join(hdrlines)
                if not self.headersMatch(msghdrs, synchHeaders[msgnum - 1]):
                    raise DeleteSynchError(errmsg % msgnum)
                else:
                    server.dele(msgnum)                                 # 可以安全删除这条消息
        finally:                                                        # 重新载入时修改消息编号
            server.quit()                                               # 退出时解锁邮箱

    def checkSynchError(self, synchHeaders):
        """
        检查列表synchHeaders中的已载入题头文本是否与服务器上的相匹配，使用POP的TOP命令抓取题头文本；
        在收件箱因其他客户端或邮件服务器自动删除邮件而变化时使用；
        如果不同步则抛出异常，或在与服务器交互时发生错误；

        为了速度起见，只检查末尾的最后部分：这样可以探知收件箱的删除，不过假定服务器不会在末尾之前插入邮件（对于新到达的邮件的确如此）；
        首先检查收件箱的大小；
        如果只发生过删除则变小；
        如果发生过删除并且新到达消息添加到尾部，top将变化，结果只有在运行时才有效：收件箱可能在返回后发生变化；
        """
        self.trace('synch check')
        errormsg = 'Message index out of synch with mail server.\n'
        errormsg += 'Mail client may require restart or reload.'
        server = self.connect()
        try:
            lastmsgnum = len(synchHeaders)                              # 从1到N
            (msgCount, msgBytes) = server.stat()                        # 收件箱大小
            if lastmsgnum > msgCount:                                   # 现在有变小吗？
                raise MessageSynchError(errormsg)
            if self.srvrHasTop:                                         # 没有用于比较的
                resp, hdrlines, respsz = server.top(lastmsgnum, 0)      # 只针对题头
                hdrlines = self.decodeFullText(hdrlines)
                lastmsghdrs = '\n'.join(hdrlines)
                if not self.headersMatch(lastmsghdrs, synchHeaders[-1]):
                    raise MessageSynchError(errormsg)
        finally:
            server.quit()

    def headersMatch(self, hdrtext1, hdrtext2):
        """"
        可能并非字符串比较这么简单：一些服务器添加一个"Status:"题头，随时间变化；
        某家ISP的题头为"Status:U"（未读），在抓取后变成"Status:RO"（已读，旧邮件）
        如果在抓取索引时是新邮件，但在删除或上一次消息检查前抓取过则不再处于同步状态；
        "Message-id:"一行在理论上对每条消息是唯一的，不过它是可选的，可以输入任何字符；
        匹配比较常见的：先试；
        解析消耗大：后试；
        """
        # 尝试通过简单字符串比较做匹配
        if hdrtext1 == hdrtext2:
            self.trace('Same headers text')
            return True

        # 尝试不带状态行做匹配
        split1 = hdrtext1.splitlines()                                  # s.split('\n'), 但是没有末尾的''
        split2 = hdrtext2.splitlines()
        strip1 = [line for line in split1 if not line.startswith('Status:')]
        strip2 = [line for line in split2 if not line.startswith('Status:')]
        if strip1 == strip2:
            self.trace('Same without Status')
            return True

        # 如果两条消息都有message-id题头，尝试排除匹配
        msgid1 = [line for line in split1 if line[:11].lower() == 'message-id:']
        msgid2 = [line for line in split2 if line[:11].lower() == 'message-id:']
        if (msgid1 or msgid2) and (msgid1 != msgid2):
            self.trace('Different Message-Id')
            return False

        # 如果消息id没有或没用，则尝试解析整个题头及常用题头
        tryheaders = ('From', 'To', 'Subject', 'Date')
        tryheaders += ('Cc', 'Return-Path', 'Received')
        msg1 = MailParser().parseHeaders(hdrtext1)
        msg2 = MailParser().parseHeaders(hdrtext2)
        for hdr in tryheaders:                                          # 可能接收到多条
            if msg1.get_all(hdr) != msg2.get_all(hdr):                  # 大小写敏感，默认为None
                self.trace('Diff common headers')
                return False

        # 所有常用题头匹配且message-id相同
        self.trace('Same common headers')
        return True

    def getPassword(self):
        """
        如果还没有POP密码则获取之
        连接到服务器之前才需要
        从客户端文件或子类方法获取
        """
        if not self.popPassword:
            try:
                localfile = open(邮件配置模块_mailconfig.poppasswdfile)
                self.popPassword = localfile.readline()[:-1]
                self.trace('local file password' + repr(self.popPassword))
            except:
                self.popPassword = self.askPopPassword()

    def askPopPassword(self):
        assert False, 'Subclass must define method'


################################################################################
# 专用子类们
################################################################################

class MailFetcherConsole(MailFetcher):
    def askPopPassword(self):
        import getpass
        prompt = 'Password for %s on %s?' % (self.popUser, self.popServer)
        return getpass.getpass(prompt)


class SilentMailFetcher(SilentMailTool, MailFetcher):
    pass                                                                # 替换追踪信息
